package daybook.io;

import daybook.Resources;
import daybook.exception.ParseException;
import daybook.model.Account;
import daybook.model.Book;
import daybook.model.Commodity;
import daybook.model.transaction.*;
import org.antlr.v4.runtime.*;
import org.antlr.v4.runtime.atn.ATNConfigSet;
import org.antlr.v4.runtime.dfa.DFA;
import org.apache.commons.lang3.StringEscapeUtils;

import java.io.IOException;
import java.io.Reader;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.*;

public final class FromString {
    private FromString() {}
    public static List<Transaction> transactions(Reader reader, Book book) throws IOException {
        final TransactionParseHandler handler = new TransactionParseHandler(book);
        final TransactionLexer lexer = new TransactionLexer(new ANTLRInputStream(reader));
        lexer.addErrorListener(handler);
        final TransactionParser parser = new TransactionParser(new CommonTokenStream(lexer));
        parser.addErrorListener(handler);
        parser.addParseListener(handler);
        parser.transactions();
        return handler.getTransactions();
    }

    private static class TransactionParseHandler extends TransactionBaseListener implements ANTLRErrorListener {
        private static final String CHECKED_TRUE = "*";
        private final Book book;
        private final List<Transaction> transactions = new ArrayList<>();
        private ImmutableTransaction.Builder transactionBuilder = null;
        private ImmutableTransactionEntry.Builder transactionEntryBuilder = null;
        private Optional<Commodity> commodity = null;
        private Throwable exception = null;

        TransactionParseHandler(Book book) {
            this.book = Objects.requireNonNull(book);
        }

        public List<Transaction> getTransactions() {
            return transactions;
        }

        private void execute(Runnable runnable) {
            if (exception != null) return;

            try {
                runnable.run();
            } catch (Throwable ex) {
                exception = ex;
                if (exception instanceof Error) {
                    throw (Error) exception;
                } else if (exception instanceof RuntimeException) {
                    throw (RuntimeException) exception;
                } else {
                    throw new ParseException(exception);
                }
            }
        }

        private static ParseException error(Token token, String msg, Object... args) {
            return error(token, null, msg, args);
        }

        private static ParseException error(Token token, Throwable cause, String msg, Object... args) {
            final String errorMessage = String.format(Resources.get(msg), args)
                    + " [at line: " + token.getLine() + ", pos: " + token.getCharPositionInLine()
                    + ", text: \""+ token.getText() + "\"]";
            return new ParseException(errorMessage, cause);
        }

        private static ParseException error(ParserRuleContext ctx, String msg, Object... args) {
            return error(ctx, null, msg, args);
        }

        private static ParseException error(ParserRuleContext ctx, Throwable cause, String msg, Object... args) {
            final String errorMessage = String.format(Resources.get(msg), args)
                    + " [at line: " + ctx.getStart().getLine() + ", pos: " + ctx.getStart().getCharPositionInLine()
                    + ", text: \""+ ctx.getText() + "\"]";
            return new ParseException(errorMessage, cause);
        }

        @Override
        public void exitTransaction(TransactionParser.TransactionContext ctx) {
            execute(() -> {
                if (transactionBuilder != null) {
                    final Transaction transaction = transactionBuilder.order(ctx.getStart().getLine()).build();
                    transactionBuilder = null;
                    final Transaction lastTransaction = transactions.isEmpty() ? null : transactions.get(transactions.size() - 1);
                    TransactionValidator.validate(lastTransaction, transaction, book.getDefaultCurrency());
                    transactions.add(transaction);
                }
            });
        }

        @Override
        public void exitTransactionHeader(TransactionParser.TransactionHeaderContext ctx) {
            execute(() -> {
                final LocalDate date = parseDate(ctx.Date().getSymbol());
                final boolean check = ctx.Check() != null && ctx.Check().getText().equals(CHECKED_TRUE);
                final String description = ctx.description == null ? "" : unescape(ctx.description.getText());

                if (transactionBuilder != null) {
                    throw new IllegalStateException("do not build old transaction. call TransactionBuildContext.build()");
                }
                transactionBuilder = ImmutableTransaction.builder()
                        .date(date)
                        .isChecked(check)
                        .description(description);
            });
        }

        private static LocalDate parseDate(Token date) {
            try {
                return LocalDate.parse(date.getText());
            } catch (DateTimeParseException e) {
                throw error(date, e, Resources.ErrorMessages.INVALID_DATE, date.getText());
            }
        }

        private static String unescape(String str) {
            return StringEscapeUtils.unescapeJava(str.substring(1, str.length() - 1));
        }

        @Override
        public void exitAccount(TransactionParser.AccountContext ctx) {
            execute(() -> {
                final String accountName = ctx.quoted != null ? unescape(ctx.quoted.getText()) : ctx.getText();
                final Account account = book.getChartOfAccounts().find(accountName)
                        .orElseThrow(() -> error(ctx, Resources.ErrorMessages.ACCOUNT_NOT_FOUND, accountName));

                if (transactionBuilder == null) {
                    throw new IllegalStateException("no new transaction is building. call TransactionBuildContext.newTransaction() first");
                }
                if (transactionEntryBuilder != null) {
                    throw new IllegalStateException("do not add old transaction entry. call TransactionBuildContext.addEntry()");
                }
                transactionEntryBuilder = ImmutableTransactionEntry.builder()
                        .account(account);
                this.commodity = account.getCommodity();
            });
        }

        @Override
        public void exitTags(TransactionParser.TagsContext ctx) {
            execute(() -> {
                ctx.tagList().Name().forEach(name -> transactionEntryBuilder.addTags(name.getText()));
            });
        }

        @Override
        public void exitItems(TransactionParser.ItemsContext ctx) {
            execute(() -> {
                final Commodity commodity = this.commodity.orElse(book.getDefaultCurrency());
                ctx.itemList().item().forEach(itemCtx -> {
                    final BigDecimal count = parseAmount(itemCtx.commodity(0), commodity);
                    final BigDecimal cost = parseAmount(itemCtx.commodity(1), book.getDefaultCurrency());

                    transactionEntryBuilder.addItems(ImmutableItem.of(count, cost));
                });
            });
        }

        private static BigDecimal parseAmount(TransactionParser.CommodityContext ctx, Commodity commodity) {
            final BigDecimal amount = parseAmount(ctx.Amount().getText());
            if (ctx.Name() != null &&
                    !ctx.Name().getText().equals(commodity.getName())) {
                throw error(ctx.Name().getSymbol(), Resources.ErrorMessages.UNEXPECTED_COMMODITY,
                        commodity.getName(), ctx.Name().getText());
            }
            try {
                return amount.setScale(commodity.getFractionDigitCount(), RoundingMode.UNNECESSARY);
            } catch (ArithmeticException e) {
                throw error(ctx.Amount().getSymbol(), e, Resources.ErrorMessages.INVALID_COMMODITY_FRACTION,
                        commodity.getName(), commodity.getFractionDigitCount());
            }
        }

        private static BigDecimal parseAmount(String amountStr) {
            final String str = amountStr.replaceAll("[ \t_]", "");
            return new BigDecimal(str);
        }

        @Override
        public void exitTransactionEntry(TransactionParser.TransactionEntryContext ctx) {
            execute(() -> {
                transactionEntryBuilder.amount(parseAmount(ctx.commodity(), book.getDefaultCurrency()));
                final String description = ctx.description == null ? "" : unescape(ctx.description.getText());

                final TransactionEntry entry = transactionEntryBuilder.description(description).build();
                transactionEntryBuilder = null;
                transactionBuilder.addEntries(entry);
            });
        }

        @Override
        public void syntaxError(Recognizer<?, ?> recognizer, Object offendingSymbol, int line, int charPositionInLine, String msg, RecognitionException e) {
            final StringBuilder sb = new StringBuilder(msg)
                    .append(" [at line: ").append(Integer.toString(line))
                    .append(", pos: ").append(Integer.toString(charPositionInLine));

            final String text = getTokenText(offendingSymbol, e);
            if (text == null) {
                sb.append("]");
            } else {
                sb.append(", text: \"").append(text).append("\"]");
            }

            this.exception = new ParseException(sb.toString(), e);
            throw (ParseException) this.exception;
        }

        private static String getTokenText(Object offendingSymbol, RecognitionException e) {
            if (offendingSymbol != null && offendingSymbol instanceof Token) {
                return ((Token) offendingSymbol).getText();
            } else if (e != null && e.getOffendingToken() != null) {
                return e.getOffendingToken().getText();
            } else {
                return null;
            }
        }

        @Override
        public void reportAmbiguity(Parser recognizer, DFA dfa, int startIndex, int stopIndex, boolean exact, BitSet ambigAlts, ATNConfigSet configs) {
            System.err.println("reportAmbiguity");
        }

        @Override
        public void reportAttemptingFullContext(Parser recognizer, DFA dfa, int startIndex, int stopIndex, BitSet conflictingAlts, ATNConfigSet configs) {
            System.err.println("reportAttemptingFullContext");
        }

        @Override
        public void reportContextSensitivity(Parser recognizer, DFA dfa, int startIndex, int stopIndex, int prediction, ATNConfigSet configs) {
            System.err.println("reportContextSensitivity");
        }
    }
}